# Copyright (c) 2018 Red Hat, Inc. All rights reserved. This copyrighted
# material is made available to anyone wishing to use, modify, copy, or
# redistribute it subject to the terms and conditions of the GNU General Public
# License v.2 or later.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
"""The "run" command."""
import argparse
from http import cookiejar
import re
import sys

from kpet import cmd_misc
from kpet import data
from kpet import misc
from kpet import patch
from kpet import run
from kpet import ssp


class ExecuteAction(argparse.Action):
    # pylint: disable=too-few-public-methods
    """Argparse action committing execution parameters to the stack."""

    def __call__(self, parser, namespace, values, option_string=None):
        """Execute the action."""
        attrs = """
            prev
            domains
            tree
            arch
            components
            sets
            tests
            kernel
            high_cost
            mboxes
            file_list
            triggered
            targeted
        """.split()
        namespace.prev = argparse.Namespace(**{
            attr: getattr(namespace, attr, None) for attr in attrs
        })


def build_common(global_group, execution_group):
    """
    Add common arguments to a "run" sub-command parser.

    Add arguments to a "run" sub-command parser, which are common between
    sub-commands.

    Args:
        global_group:       The group of global arguments to add to.
        execution_group:    The group of execution arguments to add to.
    """
    global_group.add_argument(
        '--cookies',
        metavar='FILE',
        default=None,
        help='Cookies to send when downloading patches, Netscape-format file.'
    )
    execution_group.add_argument(
        '--domains',
        metavar='REGEX',
        help='A regular expression matching names or slash-separated paths '
             'of domains to restrict the run to. Only host types belonging '
             'to the matching domains (and their subdomains) will be '
             'available to tests. Not allowed, if the database has no '
             'domains defined. '
             'Run "kpet domain tree" to see all available domains.'
    )
    execution_group.add_argument(
        '-t',
        '--tree',
        required=True,
        help='Name of the specified kernel\'s tree. '
             'Run "kpet tree list" to see recognized trees.'
    )
    execution_group.add_argument(
        '-a',
        '--arch',
        required=True,
        help='Architecture of the specified kernel. '
             'Run "kpet arch list" to see supported architectures.'
    )
    execution_group.add_argument(
        '-c',
        '--components',
        metavar='REGEX',
        help='A regular expression matching extra components included '
             'into the kernel build. '
             'Run "kpet component list" to see recognized components.'
    )
    execution_group.add_argument(
        '-s',
        '--sets',
        metavar='PATTERN',
        help='Test set pattern: regexes (fully) matching names of test sets '
             'to restrict the run to, combined using &, |, !, and () '
             'operators, which can be escaped with \\. Run "kpet set list" '
             'to see available sets.'
    )
    execution_group.add_argument(
        '--tests',
        action='append',
        metavar='REGEX',
        help='A regular expression that test names must match. '
             'Run "kpet test list" to see available tests.'
    )
    execution_group.add_argument(
        '-e',
        '--execute',
        nargs=0,
        action=ExecuteAction,
        help='Commit to an execution of the tests with execution parameters '
             'on the left. Parameter changes on the right will only affect '
             'the following executions.'
    )
    execution_group.add_argument(
        '--targeted',
        metavar=misc.ARGPARSE_TERNARY_METAVAR,
        type=misc.argparse_ternary,
        const=True,
        nargs='?',
        help='Only include tests targeting specified sources, '
             'if specified without value, or with value "true"/"yes". '
             'Only include tests not targeting specified sources, '
             'if specified with value "false"/"no". '
             'Include tests regardless of their targeted status, '
             'if not specified, or specified with value "ignore".'
    )
    execution_group.add_argument(
        '--triggered',
        metavar=misc.ARGPARSE_TERNARY_METAVAR,
        type=misc.argparse_ternary,
        const=True,
        nargs='?',
        help='Only include tests triggered by specified sources, '
             'if specified without value, or with value "true"/"yes". '
             'Only include tests not triggered by specified sources, '
             'if specified with value "false"/"no". '
             'Include tests regardless of their triggered status, '
             'if not specified, or specified with value "ignore".'
    )
    execution_group.add_argument(
        '--high-cost',
        metavar='CONDITION',
        choices=[c.lower() for c in run.HighCostCondition.__members__],
        default=run.HighCostCondition.TRIGGERED.name.lower(),
        help='Include high-cost tests according to the CONDITION - one of: '
             '"no"/"false", "targeted", "triggered", and "yes"/"true". '
             'Meaning, "never include", "include if targeted", '
             '"include if triggered", and "always include", respectively. '
             'Default is "triggered".'
    )
    execution_group.add_argument(
        '-m',
        '--mboxes',
        metavar='MBOXES',
        help='A whitespace-separated list of URLs/paths of mailboxes '
             'containing patches to extract changed files from, to trigger '
             'tests with for this execution, if any are sources. '
             'If not specified, mailboxes (and thus changed files) are not '
             'modified. If specified as empty string, changed files are '
             'considered unknown. If specified as whitespace, removes '
             'all changed files for the execution. '
             'Merged with files from -f/--file options and '
             'MBOX position arguments, for the execution. '
    )
    execution_group.add_argument(
        '--file-list',
        metavar='FILE',
        help='Similar to --mboxes processing, but directly read the file '
             'names from a file, one per line.'
    )
    global_group.add_argument(
        '-f',
        '--file',
        action='append',
        metavar='PATH',
        default=[],
        dest='files',
        help='Specify a PATH of a changed file to trigger tests with, '
             'for each execution, if it is a source. '
             'If none are specified, changed files are considered unknown. '
             'Merged with files from MBOX positional arguments and '
             'per-execution -m/--mboxes options.'
    )
    global_group.add_argument(
        'global_mboxes',
        metavar='MBOX',
        nargs='*',
        default=[],
        help='URL/path of a mailbox containing patches to extract changed '
             'files from, and to trigger tests with for each execution, '
             'if any are sources. '
             'If none are specified, mailboxes (and thus changed files) are '
             'considered unknown. '
             'Merged with files specified with -f/--file options and '
             'per-execution -m/--mboxes options. '
    )


def build_generate(parser):
    """Build the argument parser for the "run generate" command."""
    global_group = parser.add_argument_group('global parameters')
    execution_group = parser.add_argument_group('execution parameters')
    global_group.add_argument(
        '-d',
        '--description',
        default='',
        help='An arbitrary text describing the run'
    )
    global_group.add_argument(
        '-o',
        '--output',
        metavar='FILE',
        default=None,
        help='File to write the output to, default is stdout'
    )
    execution_group.add_argument(
        '-k',
        '--kernel',
        help='Kernel location. Must be accessible by Beaker.'
    )
    global_group.add_argument(
        '--no-lint',
        action='store_true',
        help='Do not lint, reformat or validate output XML'
    )
    global_group.add_argument(
        '-v',
        '--variable',
        metavar='NAME=VALUE',
        action='append',
        dest='variables',
        default=[],
        help='Assign a value to a template variable. '
             'String variables take any value, boolean variables take '
             'True/true/False/false. '
             'Run "kpet variable list" to see recognized variables.'
    )
    build_common(global_group, execution_group)


def build_test_list(parser):
    """Build the argument parser for the "run test list" command."""
    global_group = parser.add_argument_group('global parameters')
    execution_group = parser.add_argument_group('execution parameters')
    build_common(global_group, execution_group)


def build_source_list(parser):
    """Build the argument parser for the "run source list" command."""
    global_group = parser.add_argument_group('global parameters')
    execution_group = parser.add_argument_group('execution parameters')
    build_common(global_group, execution_group)
    global_group.add_argument(
        '--targeted-sources',
        metavar=misc.ARGPARSE_TERNARY_METAVAR,
        type=misc.argparse_ternary,
        const=True,
        nargs='?',
        help='Only output sources targeted by tests, '
             'if specified without value, or with value "true"/"yes". '
             'Only output sources not targeted by tests, '
             'if specified with value "false"/"no". '
             'Output sources regardless of their targeted status, '
             'if not specified, or specified with value "ignore".'
    )
    global_group.add_argument(
        '--triggered-sources',
        metavar=misc.ARGPARSE_TERNARY_METAVAR,
        type=misc.argparse_ternary,
        const=True,
        nargs='?',
        help='Only output sources triggering tests, '
             'if specified without value, or with value "true"/"yes". '
             'Only output sources not triggering tests, '
             'if specified with value "false"/"no". '
             'Output sources regardless of their triggering status, '
             'if not specified, or specified with value "ignore".'
    )


def build(cmds_parser, common_parser):
    """Build the argument parser for the "run" command."""
    _, action_subparser = cmd_misc.build(
        cmds_parser,
        common_parser,
        'run',
        help='Test suite run',
    )

    build_generate(action_subparser.add_parser(
        "generate",
        help='Generate the information required for a test run',
        parents=[common_parser],
    ))

    test_parser = action_subparser.add_parser(
        "test",
        help="",
        parents=[common_parser],
    )
    test_subaction_subparser = test_parser.add_subparsers(
        title="test_subaction",
        dest="test_subaction",
    )
    build_test_list(test_subaction_subparser.add_parser(
        "list",
        help='List tests excecuted by run',
        parents=[common_parser],
    ))

    source_parser = action_subparser.add_parser(
        "source",
        help="",
        parents=[common_parser],
    )
    source_subaction_subparser = source_parser.add_subparsers(
        title="source_subaction",
        dest="source_subaction",
    )
    build_source_list(source_subaction_subparser.add_parser(
        "list",
        help='List sources involved in a run',
        parents=[common_parser],
    ))


# pylint: disable=too-many-branches
def main_create_scene(scenario, args, database, files, cookies):
    """
    Add a scene to a scenario for test database and command-line arguments.

    Args:
        scenario:   The scenario to add the scene to.
        args:       Parsed command-line arguments for this scene.
        database:   The database to create a scenario for.
        files:      The set of changed files retrieved
                    from global command-line options.
        cookies:    The cookies to use if making HTTP requests.
    """
    target_trees = None
    target_arches = None
    target_components = set()
    match_sets = None

    if database.all_domains is None:
        if args.domains is not None:
            raise Exception("Database has no domains specified, "
                            "but the --domains option is provided")
        domains = [None]
    else:
        domains = database.all_domains
        if args.domains is not None:
            domain_regex = re.compile(args.domains)
            domains = {
                path: domain
                for path, domain in domains.items()
                if domain_regex.fullmatch(path) or
                domain_regex.fullmatch(domain.name)
            }
            if not domains:
                raise Exception(f"Regular expression {args.domains!r} "
                                f"matches no domains")

    if args.tree not in database.trees:
        raise Exception(f"Tree {args.tree!r} not found")
    target_trees = {args.tree}

    if args.arch not in database.arches:
        raise Exception(f"Architecture {args.arch!r} not found")
    if args.arch not in database.trees[args.tree]['arches']:
        raise Exception(f"Arch {args.arch!r} not supported by "
                        f"tree {args.tree!r}")
    target_arches = {args.arch}

    if args.components is not None:
        target_components = set(
            x for x in database.components
            if re.fullmatch(args.components, x)
        )
    if args.sets is not None:
        try:
            match_sets = ssp.compile(args.sets, set(database.sets))
        except (ssp.Error) as exc:
            raise Exception(
                f"Failed parsing set pattern: {repr(args.sets)}"
            ) from exc

    # If mboxes is a non-empty string
    if args.mboxes:
        files = (files or set()) | patch.get_file_set_from_location_set(
                set(args.mboxes.split()), cookies
        )
    # If a file list is provided
    if args.file_list:
        files = (files or set()) | misc.read_file_list(args.file_list)

    target = data.Target(trees=target_trees,
                         arches=target_arches,
                         components=target_components)
    high_cost = run.HighCostCondition[args.high_cost.upper()]
    scenario.add_scene(domain_paths=list(domains),
                       target=target,
                       files=files,
                       triggered=args.triggered,
                       targeted=args.targeted,
                       high_cost=high_cost,
                       match_sets=match_sets,
                       test_regexes=args.tests,
                       kernel=getattr(args, "kernel", None))


# pylint: disable=too-many-branches,too-many-locals
def main_create_scenario(args, database):
    """
    Create a scenario for specified test database and command-line arguments.

    Args:
        args:                       Parsed command-line arguments.
        database:                   The database to create a scenario for.

    Returns:
        The created scenario.
    """
    cookies = cookiejar.MozillaCookieJar()
    if args.cookies:
        cookies.load(args.cookies)
    # Merge explicitly-specified changed files and files from patches
    global_files = set(args.files) | \
        patch.get_file_set_from_location_set(set(args.global_mboxes), cookies)
    # If no global files were specified
    if not args.global_mboxes and not global_files:
        # Assume global files are unknown
        global_files = None
    scenario = run.Scenario(database)

    # For each set of execution parameters in order of appearance
    def execution_args_iter(args):
        if args:
            yield from execution_args_iter(getattr(args, "prev", None))
            yield args
    for execution_args in execution_args_iter(args):
        main_create_scene(scenario, execution_args, database,
                          global_files, cookies)

    return scenario


def main_generate(args, database):
    """
    Execute `run generate`.

    Args:
        args:       Parsed command-line arguments.
        database:   The database to generate a run for.
    """
    scenario = main_create_scenario(args, database)
    try:
        variables = scenario.assignments_parse(args.variables)
    except run.InvalidAssignments as exc:
        raise Exception("Invalid variable assignments specified "
                        "(run \"kpet variable list\" and "
                        "\"kpet run generate --help\" to see available "
                        "variables and how to specify their values)") from exc
    content = scenario.generate(description=args.description,
                                lint=not args.no_lint,
                                variables=variables)
    if not args.output:
        sys.stdout.write(content)
    else:
        with open(args.output, 'w', encoding='utf8') as file_handler:
            file_handler.write(content)


def main_test_list(args, database):
    """
    Execute `run test list`.

    Args:
        args:       Parsed command-line arguments.
        database:   The database to list the executed tests for.
    """
    scenario = main_create_scenario(args, database)
    for test_name in sorted(t.name for t in scenario.get_tests()):
        print(test_name)


def main_source_list(args, database):
    """
    Execute `run source list`.

    Args:
        args:       Parsed command-line arguments.
        database:   The database to consult about sources and tests.
    """
    scenario = main_create_scenario(args, database)
    sources = {
        source
        for source, triggered_tests in
        (scenario.get_sources_triggered_tests() or {}).items()
        if args.triggered_sources is None or
        args.triggered_sources == bool(triggered_tests)
    } & {
        source
        for source, targeted_tests in
        (scenario.get_sources_targeted_tests() or {}).items()
        if args.targeted_sources is None or
        args.targeted_sources == bool(targeted_tests)
    }
    for source in sorted(sources):
        print(source)


def main(args):
    """Execute the `run` command."""
    if not data.Base.is_dir_valid(args.db):
        misc.raise_invalid_database(args.db)
    database = data.Base(args.db)

    if args.action == 'generate':
        main_generate(args, database)
    elif args.action == 'test':
        if args.test_subaction == 'list':
            main_test_list(args, database)
        else:
            misc.raise_action_not_found(args.test_subaction, args.command)
    elif args.action == 'source':
        if args.source_subaction == 'list':
            main_source_list(args, database)
        else:
            misc.raise_action_not_found(args.source_subaction, args.command)
    else:
        misc.raise_action_not_found(args.action, args.command)
